Micron Document
baubs git

Node / mirrors / respira / commits / dade724

Commit dade72453e7e3636429943871f621e3735766cd7


Parents : 2de8cd1
Author : Jan-Henrik Bruhn <jan-henrik.bruhn@offis.de>
Date : 2025-12-27T16:22:12+01:00

refactor: Extract ProgressMonitor sub-components and utilities

- Move ProgressMonitor into dedicated folder with sub-components
- Extract ProgressStats, ProgressSection, ColorBlockList, ColorBlockItem, ProgressActions
- Create threadMetadata utility for formatting thread metadata
- Reduce ProgressMonitor.tsx from 389 to 178 lines (54% reduction)

Part of #33: Extract sub-components from large components

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

Changes
Diff

diff --git a/src/components/ProgressMonitor.tsx b/src/components/ProgressMonitor.tsx
deleted file mode 100644
index 66f6ef3..0000000
--- a/src/components/ProgressMonitor.tsx
+++ /dev/null
@@ -1,388 +0,0 @@
-import { useMemo } from "react";
-import { useAutoScroll } from "@/hooks";
-import { useShallow } from "zustand/react/shallow";
-import { useMachineStore } from "../stores/useMachineStore";
-import { usePatternStore } from "../stores/usePatternStore";
-import {
- CheckCircleIcon,
- ArrowRightIcon,
- CircleStackIcon,
- PlayIcon,
- ChartBarIcon,
- ArrowPathIcon,
-} from "@heroicons/react/24/solid";
-import { MachineStatus } from "../types/machine";
-import {
- canStartSewing,
- canStartMaskTrace,
- canResumeSewing,
-} from "../utils/machineStateHelpers";
-import { calculatePatternTime } from "../utils/timeCalculation";
-import {
- Card,
- CardHeader,
- CardTitle,
- CardDescription,
- CardContent,
-} from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { Progress } from "@/components/ui/progress";
-import { ScrollArea } from "@/components/ui/scroll-area";
-
-export function ProgressMonitor() {
- // Machine store
- const {
- machineStatus,
- patternInfo,
- sewingProgress,
- isDeleting,
- startMaskTrace,
- startSewing,
- resumeSewing,
- } = useMachineStore(
- useShallow((state) => ({
- machineStatus: state.machineStatus,
- patternInfo: state.patternInfo,
- sewingProgress: state.sewingProgress,
- isDeleting: state.isDeleting,
- startMaskTrace: state.startMaskTrace,
- startSewing: state.startSewing,
- resumeSewing: state.resumeSewing,
- })),
- );
-
- // Pattern store
- const pesData = usePatternStore((state) => state.pesData);
- const uploadedPesData = usePatternStore((state) => state.uploadedPesData);
- const displayPattern = uploadedPesData || pesData;
-
- // State indicators
- const isMaskTraceComplete =
- machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
-
- // Use PEN stitch count as fallback when machine reports 0 total stitches
- const totalStitches = patternInfo
- ? patternInfo.totalStitches === 0 && displayPattern?.penStitches
- ? displayPattern.penStitches.stitches.length
- : patternInfo.totalStitches
- : 0;
-
- const progressPercent =
- totalStitches > 0
- ? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100
- : 0;
-
- // Calculate color block information from decoded penStitches
- const colorBlocks = useMemo(() => {
- if (!displayPattern || !displayPattern.penStitches) return [];
-
- const blocks: Array<{
- colorIndex: number;
- threadHex: string;
- startStitch: number;
- endStitch: number;
- stitchCount: number;
- threadCatalogNumber: string | null;
- threadBrand: string | null;
- threadDescription: string | null;
- threadChart: string | null;
- }> = [];
-
- // Use the pre-computed color blocks from decoded PEN data
- for (const penBlock of displayPattern.penStitches.colorBlocks) {
- const thread = displayPattern.threads[penBlock.colorIndex];
- blocks.push({
- colorIndex: penBlock.colorIndex,
- threadHex: thread?.hex || "#000000",
- threadCatalogNumber: thread?.catalogNumber ?? null,
- threadBrand: thread?.brand ?? null,
- threadDescription: thread?.description ?? null,
- threadChart: thread?.chart ?? null,
- startStitch: penBlock.startStitchIndex,
- endStitch: penBlock.endStitchIndex,
- stitchCount: penBlock.endStitchIndex - penBlock.startStitchIndex,
- });
- }
-
- return blocks;
- }, [displayPattern]);
-
- // Determine current color block based on current stitch
- const currentStitch = sewingProgress?.currentStitch || 0;
- const currentBlockIndex = colorBlocks.findIndex(
- (block) =>
- currentStitch >= block.startStitch && currentStitch < block.endStitch,
- );
-
- // Calculate time based on color blocks (matches Brother app calculation)
- const { totalMinutes, elapsedMinutes } = useMemo(() => {
- if (colorBlocks.length === 0) {
- return { totalMinutes: 0, elapsedMinutes: 0 };
- }
- const result = calculatePatternTime(colorBlocks, currentStitch);
- return {
- totalMinutes: result.totalMinutes,
- elapsedMinutes: result.elapsedMinutes,
- };
- }, [colorBlocks, currentStitch]);
-
- // Auto-scroll to current block
- const currentBlockRef = useAutoScroll<HTMLDivElement>(currentBlockIndex);
-
- return (
- <Card className="p-0 gap-0 lg:h-full border-l-4 border-accent-600 dark:border-accent-500 flex flex-col lg:overflow-hidden">
- <CardHeader className="p-4 pb-3">
- <div className="flex items-start gap-3">
- <ChartBarIcon className="w-6 h-6 text-accent-600 dark:text-accent-400 flex-shrink-0 mt-0.5" />
- <div className="flex-1 min-w-0">
- <CardTitle className="text-sm">Sewing Progress</CardTitle>
- {sewingProgress && (
- <CardDescription className="text-xs">
- {progressPercent.toFixed(1)}% complete
- </CardDescription>
- )}
- </div>
- </div>
- </CardHeader>
- <CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col lg:overflow-hidden">
- {/* Pattern Info */}
- {patternInfo && (
- <div className="grid grid-cols-3 gap-2 text-xs mb-3">
- <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
- <span className="text-gray-600 dark:text-gray-400 block">
- Total Stitches
- </span>
- <span className="font-semibold text-gray-900 dark:text-gray-100">
- {totalStitches.toLocaleString()}
- </span>
- </div>
- <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
- <span className="text-gray-600 dark:text-gray-400 block">
- Total Time
- </span>
- <span className="font-semibold text-gray-900 dark:text-gray-100">
- {totalMinutes} min
- </span>
- </div>
- <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
- <span className="text-gray-600 dark:text-gray-400 block">
- Speed
- </span>
- <span className="font-semibold text-gray-900 dark:text-gray-100">
- {patternInfo.speed} spm
- </span>
- </div>
- </div>
- )}
-
- {/* Progress Bar */}
- {sewingProgress && (
- <div className="mb-3">
- <Progress
- value={progressPercent}
- className="h-3 mb-2 [&>div]:bg-gradient-to-r [&>div]:from-accent-600 [&>div]:to-accent-700 dark:[&>div]:from-accent-600 dark:[&>div]:to-accent-800"
- />
-
- <div className="grid grid-cols-2 gap-2 text-xs mb-3">
- <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
- <span className="text-gray-600 dark:text-gray-400 block">
- Current Stitch
- </span>
- <span className="font-semibold text-gray-900 dark:text-gray-100">
- {sewingProgress.currentStitch.toLocaleString()} /{" "}
- {totalStitches.toLocaleString()}
- </span>
- </div>
- <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
- <span className="text-gray-600 dark:text-gray-400 block">
- Time
- </span>
- <span className="font-semibold text-gray-900 dark:text-gray-100">
- {elapsedMinutes} / {totalMinutes} min
- </span>
- </div>
- </div>
- </div>
- )}
-
- {/* Color Blocks */}
- {colorBlocks.length > 0 && (
- <div className="mb-3 lg:flex-1 lg:min-h-0 flex flex-col">
- <h4 className="text-xs font-semibold mb-2 text-gray-700 dark:text-gray-300 flex-shrink-0">
- Color Blocks
- </h4>
- <ScrollArea className="lg:flex-1 lg:h-0">
- <div className="flex flex-col gap-2 pr-4">
- {colorBlocks.map((block, index) => {
- const isCompleted = currentStitch >= block.endStitch;
- const isCurrent = index === currentBlockIndex;
-
- // Calculate progress within current block
- let blockProgress = 0;
- if (isCurrent) {
- blockProgress =
- ((currentStitch - block.startStitch) /
- block.stitchCount) *
- 100;
- } else if (isCompleted) {
- blockProgress = 100;
- }
-
- return (
- <div
- key={index}
- ref={isCurrent ? currentBlockRef : null}
- className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
- isCompleted
- ? "border-success-600 bg-success-50 dark:bg-success-900/20"
- : isCurrent
- ? "border-gray-400 dark:border-gray-500 bg-white dark:bg-gray-700"
- : "border-gray-200 dark:border-gray-600 bg-gray-100 dark:bg-gray-800/50 opacity-70"
- }`}
- role="listitem"
- aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? "completed" : isCurrent ? "in progress" : "pending"}`}
- >
- <div className="flex items-center gap-2.5">
- {/* Color swatch */}
- <div
- className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0"
- style={{
- backgroundColor: block.threadHex,
- }}
- title={`Thread color: ${block.threadHex}`}
- aria-label={`Thread color ${block.threadHex}`}
- />
-
- {/* Thread info */}
- <div className="flex-1 min-w-0">
- <div className="font-semibold text-xs text-gray-900 dark:text-gray-100">
- Thread {block.colorIndex + 1}
- {(block.threadBrand ||
- block.threadChart ||
- block.threadDescription ||
- block.threadCatalogNumber) && (
- <span className="font-normal text-gray-600 dark:text-gray-400">
- {" "}
- (
- {(() => {
- // Primary metadata: brand and catalog number
- const primaryMetadata = [
- block.threadBrand,
- block.threadCatalogNumber
- ? `#${block.threadCatalogNumber}`
- : null,
- ]
- .filter(Boolean)
- .join(" ");
-
- // Secondary metadata: chart and description
- // Only show chart if it's different from catalogNumber
- const secondaryMetadata = [
- block.threadChart &&
- block.threadChart !==
- block.threadCatalogNumber
- ? block.threadChart
- : null,
- block.threadDescription,
- ]
- .filter(Boolean)
- .join(" ");
-
- return [primaryMetadata, secondaryMetadata]
- .filter(Boolean)
- .join(" • ");
- })()}
- )
- </span>
- )}
- </div>
- <div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
- {block.stitchCount.toLocaleString()} stitches
- </div>
- </div>
-
- {/* Status icon */}
- {isCompleted ? (
- <CheckCircleIcon
- className="w-5 h-5 text-success-600 flex-shrink-0"
- aria-label="Completed"
- />
- ) : isCurrent ? (
- <ArrowRightIcon
- className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 animate-pulse"
- aria-label="In progress"
- />
- ) : (
- <CircleStackIcon
- className="w-5 h-5 text-gray-400 flex-shrink-0"
- aria-label="Pending"
- />
- )}
- </div>
-
- {/* Progress bar for current block */}
- {isCurrent && (
- <Progress
- value={blockProgress}
- className="mt-2 h-1.5 [&>div]:bg-gray-600 dark:[&>div]:bg-gray-500"
- aria-label={`${Math.round(blockProgress)}% complete`}
- />
- )}
- </div>
- );
- })}
- </div>
- </ScrollArea>
- </div>
- )}
-
- {/* Action buttons */}
- <div className="flex gap-2 flex-shrink-0">
- {/* Resume has highest priority when available */}
- {canResumeSewing(machineStatus) && (
- <Button
- onClick={resumeSewing}
- disabled={isDeleting}
- className="flex-1"
- aria-label="Resume sewing the current pattern"
- >
- <PlayIcon className="w-3.5 h-3.5" />
- Resume Sewing
- </Button>
- )}
-
- {/* Start Sewing - primary action, takes more space */}
- {canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
- <Button
- onClick={startSewing}
- disabled={isDeleting}
- className="flex-[2]"
- aria-label="Start sewing the pattern"
- >
- <PlayIcon className="w-3.5 h-3.5" />
- Start Sewing
- </Button>
- )}
-
- {/* Start Mask Trace - secondary action */}
- {canStartMaskTrace(machineStatus) && (
- <Button
- onClick={startMaskTrace}
- disabled={isDeleting}
- variant="outline"
- className="flex-1"
- aria-label={
- isMaskTraceComplete
- ? "Start mask trace again"
- : "Start mask trace"
- }
- >
- <ArrowPathIcon className="w-3.5 h-3.5" />
- {isMaskTraceComplete ? "Trace Again" : "Start Mask Trace"}
- </Button>
- )}
- </div>
- </CardContent>
- </Card>
- );
-}

diff --git a/src/components/ProgressMonitor/ColorBlockItem.tsx b/src/components/ProgressMonitor/ColorBlockItem.tsx
new file mode 100644
index 0000000..fb21fcd
--- /dev/null
+++ b/src/components/ProgressMonitor/ColorBlockItem.tsx
@@ -0,0 +1,126 @@
+/**
+ * ColorBlockItem Component
+ *
+ * Renders an individual color block card with thread metadata, stitch count, status icon, and progress
+ */
+
+import { forwardRef } from "react";
+import {
+ CheckCircleIcon,
+ ArrowRightIcon,
+ CircleStackIcon,
+} from "@heroicons/react/24/solid";
+import { Progress } from "@/components/ui/progress";
+import { formatThreadMetadata } from "../../utils/threadMetadata";
+
+export interface ColorBlock {
+ colorIndex: number;
+ threadHex: string;
+ startStitch: number;
+ endStitch: number;
+ stitchCount: number;
+ threadCatalogNumber: string | null;
+ threadBrand: string | null;
+ threadDescription: string | null;
+ threadChart: string | null;
+}
+
+interface ColorBlockItemProps {
+ block: ColorBlock;
+ index: number;
+ currentStitch: number;
+ isCurrent: boolean;
+ isCompleted: boolean;
+}
+
+export const ColorBlockItem = forwardRef<HTMLDivElement, ColorBlockItemProps>(
+ ({ block, index, currentStitch, isCurrent, isCompleted }, ref) => {
+ // Calculate progress within current block
+ let blockProgress = 0;
+ if (isCurrent) {
+ blockProgress =
+ ((currentStitch - block.startStitch) / block.stitchCount) * 100;
+ } else if (isCompleted) {
+ blockProgress = 100;
+ }
+
+ const hasMetadata =
+ block.threadBrand ||
+ block.threadChart ||
+ block.threadDescription ||
+ block.threadCatalogNumber;
+
+ return (
+ <div
+ key={index}
+ ref={isCurrent ? ref : null}
+ className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
+ isCompleted
+ ? "border-success-600 bg-success-50 dark:bg-success-900/20"
+ : isCurrent
+ ? "border-gray-400 dark:border-gray-500 bg-white dark:bg-gray-700"
+ : "border-gray-200 dark:border-gray-600 bg-gray-100 dark:bg-gray-800/50 opacity-70"
+ }`}
+ role="listitem"
+ aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? "completed" : isCurrent ? "in progress" : "pending"}`}
+ >
+ <div className="flex items-center gap-2.5">
+ {/* Color swatch */}
+ <div
+ className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0"
+ style={{
+ backgroundColor: block.threadHex,
+ }}
+ title={`Thread color: ${block.threadHex}`}
+ aria-label={`Thread color ${block.threadHex}`}
+ />
+
+ {/* Thread info */}
+ <div className="flex-1 min-w-0">
+ <div className="font-semibold text-xs text-gray-900 dark:text-gray-100">
+ Thread {block.colorIndex + 1}
+ {hasMetadata && (
+ <span className="font-normal text-gray-600 dark:text-gray-400">
+ {" "}
+ ({formatThreadMetadata(block)})
+ </span>
+ )}
+ </div>
+ <div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
+ {block.stitchCount.toLocaleString()} stitches
+ </div>
+ </div>
+
+ {/* Status icon */}
+ {isCompleted ? (
+ <CheckCircleIcon
+ className="w-5 h-5 text-success-600 flex-shrink-0"
+ aria-label="Completed"
+ />
+ ) : isCurrent ? (
+ <ArrowRightIcon
+ className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 animate-pulse"
+ aria-label="In progress"
+ />
+ ) : (
+ <CircleStackIcon
+ className="w-5 h-5 text-gray-400 flex-shrink-0"
+ aria-label="Pending"
+ />
+ )}
+ </div>
+
+ {/* Progress bar for current block */}
+ {isCurrent && (
+ <Progress
+ value={blockProgress}
+ className="mt-2 h-1.5 [&>div]:bg-gray-600 dark:[&>div]:bg-gray-500"
+ aria-label={`${Math.round(blockProgress)}% complete`}
+ />
+ )}
+ </div>
+ );
+ },
+);
+
+ColorBlockItem.displayName = "ColorBlockItem";

diff --git a/src/components/ProgressMonitor/ColorBlockList.tsx b/src/components/ProgressMonitor/ColorBlockList.tsx
new file mode 100644
index 0000000..0ae0df2
--- /dev/null
+++ b/src/components/ProgressMonitor/ColorBlockList.tsx
@@ -0,0 +1,52 @@
+/**
+ * ColorBlockList Component
+ *
+ * Container for the scrollable list of color blocks
+ */
+
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { ColorBlockItem, type ColorBlock } from "./ColorBlockItem";
+
+interface ColorBlockListProps {
+ colorBlocks: ColorBlock[];
+ currentStitch: number;
+ currentBlockIndex: number;
+ currentBlockRef: React.RefObject<HTMLDivElement | null>;
+}
+
+export function ColorBlockList({
+ colorBlocks,
+ currentStitch,
+ currentBlockIndex,
+ currentBlockRef,
+}: ColorBlockListProps) {
+ if (colorBlocks.length === 0) return null;
+
+ return (
+ <div className="mb-3 lg:flex-1 lg:min-h-0 flex flex-col">
+ <h4 className="text-xs font-semibold mb-2 text-gray-700 dark:text-gray-300 flex-shrink-0">
+ Color Blocks
+ </h4>
+ <ScrollArea className="lg:flex-1 lg:h-0">
+ <div className="flex flex-col gap-2 pr-4">
+ {colorBlocks.map((block, index) => {
+ const isCompleted = currentStitch >= block.endStitch;
+ const isCurrent = index === currentBlockIndex;
+
+ return (
+ <ColorBlockItem
+ key={index}
+ ref={isCurrent ? currentBlockRef : null}
+ block={block}
+ index={index}
+ currentStitch={currentStitch}
+ isCurrent={isCurrent}
+ isCompleted={isCompleted}
+ />
+ );
+ })}
+ </div>
+ </ScrollArea>
+ </div>
+ );
+}

diff --git a/src/components/ProgressMonitor/ProgressActions.tsx b/src/components/ProgressMonitor/ProgressActions.tsx
new file mode 100644
index 0000000..401a9f2
--- /dev/null
+++ b/src/components/ProgressMonitor/ProgressActions.tsx
@@ -0,0 +1,78 @@
+/**
+ * ProgressActions Component
+ *
+ * Renders action buttons (Resume Sewing, Start Sewing, Start Mask Trace)
+ */
+
+import { PlayIcon, ArrowPathIcon } from "@heroicons/react/24/solid";
+import { Button } from "@/components/ui/button";
+import { MachineStatus } from "../../types/machine";
+import {
+ canStartSewing,
+ canStartMaskTrace,
+ canResumeSewing,
+} from "../../utils/machineStateHelpers";
+
+interface ProgressActionsProps {
+ machineStatus: MachineStatus;
+ isDeleting: boolean;
+ isMaskTraceComplete: boolean;
+ onResumeSewing: () => void;
+ onStartSewing: () => void;
+ onStartMaskTrace: () => void;
+}
+
+export function ProgressActions({
+ machineStatus,
+ isDeleting,
+ isMaskTraceComplete,
+ onResumeSewing,
+ onStartSewing,
+ onStartMaskTrace,
+}: ProgressActionsProps) {
+ return (
+ <div className="flex gap-2 flex-shrink-0">
+ {/* Resume has highest priority when available */}
+ {canResumeSewing(machineStatus) && (
+ <Button
+ onClick={onResumeSewing}
+ disabled={isDeleting}
+ className="flex-1"
+ aria-label="Resume sewing the current pattern"
+ >
+ <PlayIcon className="w-3.5 h-3.5" />
+ Resume Sewing
+ </Button>
+ )}
+
+ {/* Start Sewing - primary action, takes more space */}
+ {canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
+ <Button
+ onClick={onStartSewing}
+ disabled={isDeleting}
+ className="flex-[2]"
+ aria-label="Start sewing the pattern"
+ >
+ <PlayIcon className="w-3.5 h-3.5" />
+ Start Sewing
+ </Button>
+ )}
+
+ {/* Start Mask Trace - secondary action */}
+ {canStartMaskTrace(machineStatus) && (
+ <Button
+ onClick={onStartMaskTrace}
+ disabled={isDeleting}
+ variant="outline"
+ className="flex-1"
+ aria-label={
+ isMaskTraceComplete ? "Start mask trace again" : "Start mask trace"
+ }
+ >
+ <ArrowPathIcon className="w-3.5 h-3.5" />
+ {isMaskTraceComplete ? "Trace Again" : "Start Mask Trace"}
+ </Button>
+ )}
+ </div>
+ );
+}

diff --git a/src/components/ProgressMonitor/ProgressMonitor.tsx b/src/components/ProgressMonitor/ProgressMonitor.tsx
new file mode 100644
index 0000000..4947035
--- /dev/null
+++ b/src/components/ProgressMonitor/ProgressMonitor.tsx
@@ -0,0 +1,174 @@
+/**
+ * ProgressMonitor Component
+ *
+ * Orchestrates progress monitoring UI with stats, progress bar, color blocks, and action buttons
+ */
+
+import { useMemo } from "react";
+import { useAutoScroll } from "@/hooks";
+import { useShallow } from "zustand/react/shallow";
+import { useMachineStore } from "../../stores/useMachineStore";
+import { usePatternStore } from "../../stores/usePatternStore";
+import { ChartBarIcon } from "@heroicons/react/24/solid";
+import { MachineStatus } from "../../types/machine";
+import { calculatePatternTime } from "../../utils/timeCalculation";
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+} from "@/components/ui/card";
+import { ProgressStats } from "./ProgressStats";
+import { ProgressSection } from "./ProgressSection";
+import { ColorBlockList } from "./ColorBlockList";
+import { ProgressActions } from "./ProgressActions";
+import type { ColorBlock } from "./ColorBlockItem";
+
+export function ProgressMonitor() {
+ // Machine store
+ const {
+ machineStatus,
+ patternInfo,
+ sewingProgress,
+ isDeleting,
+ startMaskTrace,
+ startSewing,
+ resumeSewing,
+ } = useMachineStore(
+ useShallow((state) => ({
+ machineStatus: state.machineStatus,
+ patternInfo: state.patternInfo,
+ sewingProgress: state.sewingProgress,
+ isDeleting: state.isDeleting,
+ startMaskTrace: state.startMaskTrace,
+ startSewing: state.startSewing,
+ resumeSewing: state.resumeSewing,
+ })),
+ );
+
+ // Pattern store
+ const pesData = usePatternStore((state) => state.pesData);
+ const uploadedPesData = usePatternStore((state) => state.uploadedPesData);
+ const displayPattern = uploadedPesData || pesData;
+
+ // State indicators
+ const isMaskTraceComplete =
+ machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
+
+ // Use PEN stitch count as fallback when machine reports 0 total stitches
+ const totalStitches = patternInfo
+ ? patternInfo.totalStitches === 0 && displayPattern?.penStitches
+ ? displayPattern.penStitches.stitches.length
+ : patternInfo.totalStitches
+ : 0;
+
+ const progressPercent =
+ totalStitches > 0
+ ? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100
+ : 0;
+
+ // Calculate color block information from decoded penStitches
+ const colorBlocks = useMemo(() => {
+ if (!displayPattern || !displayPattern.penStitches) return [];
+
+ const blocks: ColorBlock[] = [];
+
+ // Use the pre-computed color blocks from decoded PEN data
+ for (const penBlock of displayPattern.penStitches.colorBlocks) {
+ const thread = displayPattern.threads[penBlock.colorIndex];
+ blocks.push({
+ colorIndex: penBlock.colorIndex,
+ threadHex: thread?.hex || "#000000",
+ threadCatalogNumber: thread?.catalogNumber ?? null,
+ threadBrand: thread?.brand ?? null,
+ threadDescription: thread?.description ?? null,
+ threadChart: thread?.chart ?? null,
+ startStitch: penBlock.startStitchIndex,
+ endStitch: penBlock.endStitchIndex,
+ stitchCount: penBlock.endStitchIndex - penBlock.startStitchIndex,
+ });
+ }
+
+ return blocks;
+ }, [displayPattern]);
+
+ // Determine current color block based on current stitch
+ const currentStitch = sewingProgress?.currentStitch || 0;
+ const currentBlockIndex = colorBlocks.findIndex(
+ (block) =>
+ currentStitch >= block.startStitch && currentStitch < block.endStitch,
+ );
+
+ // Calculate time based on color blocks (matches Brother app calculation)
+ const { totalMinutes, elapsedMinutes } = useMemo(() => {
+ if (colorBlocks.length === 0) {
+ return { totalMinutes: 0, elapsedMinutes: 0 };
+ }
+ const result = calculatePatternTime(colorBlocks, currentStitch);
+ return {
+ totalMinutes: result.totalMinutes,
+ elapsedMinutes: result.elapsedMinutes,
+ };
+ }, [colorBlocks, currentStitch]);
+
+ // Auto-scroll to current block
+ const currentBlockRef = useAutoScroll<HTMLDivElement>(currentBlockIndex);
+
+ return (
+ <Card className="p-0 gap-0 lg:h-full border-l-4 border-accent-600 dark:border-accent-500 flex flex-col lg:overflow-hidden">
+ <CardHeader className="p-4 pb-3">
+ <div className="flex items-start gap-3">
+ <ChartBarIcon className="w-6 h-6 text-accent-600 dark:text-accent-400 flex-shrink-0 mt-0.5" />
+ <div className="flex-1 min-w-0">
+ <CardTitle className="text-sm">Sewing Progress</CardTitle>
+ {sewingProgress && (
+ <CardDescription className="text-xs">
+ {progressPercent.toFixed(1)}% complete
+ </CardDescription>
+ )}
+ </div>
+ </div>
+ </CardHeader>
+ <CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col lg:overflow-hidden">
+ {/* Pattern Info */}
+ {patternInfo && (
+ <ProgressStats
+ totalStitches={totalStitches}
+ totalMinutes={totalMinutes}
+ speed={patternInfo.speed}
+ />
+ )}
+
+ {/* Progress Bar */}
+ {sewingProgress && (
+ <ProgressSection
+ currentStitch={sewingProgress.currentStitch}
+ totalStitches={totalStitches}
+ elapsedMinutes={elapsedMinutes}
+ totalMinutes={totalMinutes}
+ progressPercent={progressPercent}
+ />
+ )}
+
+ {/* Color Blocks */}
+ <ColorBlockList
+ colorBlocks={colorBlocks}
+ currentStitch={currentStitch}
+ currentBlockIndex={currentBlockIndex}
+ currentBlockRef={currentBlockRef}
+ />
+
+ {/* Action buttons */}
+ <ProgressActions
+ machineStatus={machineStatus}
+ isDeleting={isDeleting}
+ isMaskTraceComplete={isMaskTraceComplete}
+ onResumeSewing={resumeSewing}
+ onStartSewing={startSewing}
+ onStartMaskTrace={startMaskTrace}
+ />
+ </CardContent>
+ </Card>
+ );
+}

diff --git a/src/components/ProgressMonitor/ProgressSection.tsx b/src/components/ProgressMonitor/ProgressSection.tsx
new file mode 100644
index 0000000..4370e13
--- /dev/null
+++ b/src/components/ProgressMonitor/ProgressSection.tsx
@@ -0,0 +1,49 @@
+/**
+ * ProgressSection Component
+ *
+ * Displays the progress bar and current/total stitch information
+ */
+
+import { Progress } from "@/components/ui/progress";
+
+interface ProgressSectionProps {
+ currentStitch: number;
+ totalStitches: number;
+ elapsedMinutes: number;
+ totalMinutes: number;
+ progressPercent: number;
+}
+
+export function ProgressSection({
+ currentStitch,
+ totalStitches,
+ elapsedMinutes,
+ totalMinutes,
+ progressPercent,
+}: ProgressSectionProps) {
+ return (
+ <div className="mb-3">
+ <Progress
+ value={progressPercent}
+ className="h-3 mb-2 [&>div]:bg-gradient-to-r [&>div]:from-accent-600 [&>div]:to-accent-700 dark:[&>div]:from-accent-600 dark:[&>div]:to-accent-800"
+ />
+
+ <div className="grid grid-cols-2 gap-2 text-xs mb-3">
+ <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
+ <span className="text-gray-600 dark:text-gray-400 block">
+ Current Stitch
+ </span>
+ <span className="font-semibold text-gray-900 dark:text-gray-100">
+ {currentStitch.toLocaleString()} / {totalStitches.toLocaleString()}
+ </span>
+ </div>
+ <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
+ <span className="text-gray-600 dark:text-gray-400 block">Time</span>
+ <span className="font-semibold text-gray-900 dark:text-gray-100">
+ {elapsedMinutes} / {totalMinutes} min
+ </span>
+ </div>
+ </div>
+ </div>
+ );
+}

diff --git a/src/components/ProgressMonitor/ProgressStats.tsx b/src/components/ProgressMonitor/ProgressStats.tsx
new file mode 100644
index 0000000..d65945c
--- /dev/null
+++ b/src/components/ProgressMonitor/ProgressStats.tsx
@@ -0,0 +1,44 @@
+/**
+ * ProgressStats Component
+ *
+ * Displays three stat cards: total stitches, total time, and speed
+ */
+
+interface ProgressStatsProps {
+ totalStitches: number;
+ totalMinutes: number;
+ speed: number;
+}
+
+export function ProgressStats({
+ totalStitches,
+ totalMinutes,
+ speed,
+}: ProgressStatsProps) {
+ return (
+ <div className="grid grid-cols-3 gap-2 text-xs mb-3">
+ <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
+ <span className="text-gray-600 dark:text-gray-400 block">
+ Total Stitches
+ </span>
+ <span className="font-semibold text-gray-900 dark:text-gray-100">
+ {totalStitches.toLocaleString()}
+ </span>
+ </div>
+ <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
+ <span className="text-gray-600 dark:text-gray-400 block">
+ Total Time
+ </span>
+ <span className="font-semibold text-gray-900 dark:text-gray-100">
+ {totalMinutes} min
+ </span>
+ </div>
+ <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
+ <span className="text-gray-600 dark:text-gray-400 block">Speed</span>
+ <span className="font-semibold text-gray-900 dark:text-gray-100">
+ {speed} spm
+ </span>
+ </div>
+ </div>
+ );
+}

diff --git a/src/components/ProgressMonitor/index.ts b/src/components/ProgressMonitor/index.ts
new file mode 100644
index 0000000..6c59a59
--- /dev/null
+++ b/src/components/ProgressMonitor/index.ts
@@ -0,0 +1,5 @@
+/**
+ * ProgressMonitor component barrel export
+ */
+
+export { ProgressMonitor } from "./ProgressMonitor";

diff --git a/src/components/WorkflowStepper/WorkflowStepper.tsx b/src/components/WorkflowStepper/WorkflowStepper.tsx
index c065ced..b2aebf8 100644
--- a/src/components/WorkflowStepper/WorkflowStepper.tsx
+++ b/src/components/WorkflowStepper/WorkflowStepper.tsx
@@ -7,7 +7,10 @@
import { useState, useRef } from "react";
import { useClickOutside } from "@/hooks";
import { useShallow } from "zustand/react/shallow";
-import { useMachineStore, usePatternUploaded } from "../../stores/useMachineStore";
+import {
+ useMachineStore,
+ usePatternUploaded,
+} from "../../stores/useMachineStore";
import { usePatternStore } from "../../stores/usePatternStore";
import { WORKFLOW_STEPS } from "../../constants/workflowSteps";
import { getCurrentStep } from "../../utils/workflowStepCalculation";

diff --git a/src/utils/threadMetadata.ts b/src/utils/threadMetadata.ts
new file mode 100644
index 0000000..5433b10
--- /dev/null
+++ b/src/utils/threadMetadata.ts
@@ -0,0 +1,34 @@
+/**
+ * Format thread metadata for display
+ * Combines brand, catalog number, chart, and description into a readable string
+ */
+
+interface ThreadMetadata {
+ threadBrand: string | null;
+ threadCatalogNumber: string | null;
+ threadChart: string | null;
+ threadDescription: string | null;
+}
+
+export function formatThreadMetadata(thread: ThreadMetadata): string {
+ // Primary metadata: brand and catalog number
+ const primaryMetadata = [
+ thread.threadBrand,
+ thread.threadCatalogNumber ? `#${thread.threadCatalogNumber}` : null,
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ // Secondary metadata: chart and description
+ // Only show chart if it's different from catalogNumber
+ const secondaryMetadata = [
+ thread.threadChart && thread.threadChart !== thread.threadCatalogNumber
+ ? thread.threadChart
+ : null,
+ thread.threadDescription,
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ return [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
+}

Served by rngit 1.3.3 - Generated in 0.07s